Skip to content

Add confirmed repository deletion tool - #3076

Merged
SamMorrowDrums merged 17 commits into
mainfrom
sammorrowdrums-add-delete-repository-tool
Aug 18, 2026
Merged

Add confirmed repository deletion tool#3076
SamMorrowDrums merged 17 commits into
mainfrom
sammorrowdrums-add-delete-repository-tool

Conversation

@SamMorrowDrums

@SamMorrowDrums SamMorrowDrums commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a destructive delete_repository MCP tool that deletes a repository only after the user enters the exact owner/repo name through elicitation. The tool is exposed only for MCP protocol 2026-07-28 and newer when the client supports form elicitation.

Why

Repository deletion needs a stronger confirmation boundary than ordinary write operations. Self-hosted stateless HTTP deployments also need authenticated encryption for client-held MRTR request state so retries cannot alter the confirmed target.

N/A - no linked issue.

What changed

  • Added delete_repository with the delete_repo OAuth scope, destructive annotations, and exact repository-name confirmation.
  • Added reusable server-tool availability metadata for minimum protocol versions and required elicitation modes.
  • Filtered unavailable tools from tools/list and enforced direct calls inside registered handlers across stdio and remote HTTP transports.
  • Bound the deletion target into encrypted MRTR request state for self-hosted HTTP using GITHUB_MCP_SERVER_MRTR_STATE_KEY.
  • Added an optional github.RequestStateSealer provider contract so hosted integrations can retain their existing key format without changing ToolDependencies.
  • Added unit, in-memory multi-round-trip, remote HTTP availability, tamper, target-binding, and inventory-fallback coverage plus the generated tool snapshot and documentation.

MCP impact

  • No tool or API changes
  • Tool schema or behavior changed
  • New tool added

The new schema accepts owner and repo; execution then requests repository_name through multi-round-trip form elicitation before calling GitHub's delete repository API. HTTP mode exposes the tool only when a valid request-state encryption key is configured.

Prompts tested (tool changes only)

  • "Delete the owner/repo repository" - automated in-memory MCP coverage verifies elicitation, exact matching, refusal paths, tamper rejection, target binding, and successful deletion.

Security / limits

  • No security or limits impact
  • Auth / permissions considered
  • Data exposure, filtering, or token/size limits considered

The tool requires the dedicated delete_repo OAuth scope, refuses declined or mismatched confirmation, and is hidden and refused unless the request uses protocol 2026-07-28 or newer and advertises form elicitation support. Self-hosted HTTP uses AES-256-GCM request-state protection; missing keys hide only this tool and malformed keys fail startup.

Tool renaming

  • I am renaming tools as part of this PR (e.g. a part of a consolidation effort)
    • I have added the new tool aliases in deprecated_tool_aliases.go
  • I am not renaming tools as part of this PR

Note: if you're renaming tools, you must add the tool aliases. For more information on how to do so, please refer to the official docs.

Lint & tests

  • Linted locally with ./script/lint
  • Tested locally with ./script/test

Docs

  • Not needed
  • Updated (README / docs / examples)

Add a destructive delete_repository tool that requires an exact owner/repo confirmation through multi-round-trip elicitation. Gate the tool to MCP protocol 2026-07-28 and newer across local and remote transports.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
@SamMorrowDrums
SamMorrowDrums requested a review from a team as a code owner August 14, 2026 22:44
Copilot AI balanced review requested due to automatic review settings August 14, 2026 22:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds protocol-gated, elicitation-confirmed repository deletion.

Changes:

  • Adds delete_repository with delete_repo scope and destructive annotations.
  • Filters tools by minimum MCP protocol version.
  • Adds schema, unit, transport, and documentation updates.
Show a summary per file
File Description
README.md Documents the new tool.
pkg/scopes/scopes.go Defines delete_repo.
pkg/scopes/scopes_test.go Tests scope expansion.
pkg/inventory/server_tool.go Adds minimum protocol metadata.
pkg/inventory/registry.go Installs protocol filtering.
pkg/inventory/protocol_version.go Implements listing/call filtering.
pkg/inventory/protocol_version_test.go Tests protocol gating.
pkg/http/handler_test.go Tests HTTP tool visibility.
pkg/github/tools.go Registers the deletion tool.
pkg/github/repositories.go Implements confirmation and deletion.
pkg/github/repositories_test.go Tests deletion and elicitation.
pkg/github/helper_test.go Adds the mock endpoint constant.
pkg/github/__toolsnaps__/delete_repository.snap Captures the tool schema.
internal/ghmcp/oauth.go Reuses the protocol constant.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread pkg/github/repositories_test.go
Gate protocol-restricted tools on required elicitation capabilities and enforce direct calls inside the registered handler so SDK result finalization remains intact.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
Seal repository deletion targets for self-hosted HTTP with a stable AES-256-GCM key. Hide only delete_repository when no key is configured and expose an optional sealer interface for remote integrators.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
Comment thread pkg/github/repositories.go Outdated
Bind sealed repository deletion state to the immutable repository ID and a ten-minute expiry. Re-check identity before deletion so replay cannot affect a recreated repository.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
…lete-repository-tool

# Conflicts:
#	pkg/http/handler_test.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 23/23 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread pkg/http/handler.go Outdated
Comment thread pkg/github/repositories.go Outdated
Apply static allowlists before removing unavailable tools and fail closed on invalid configured tool names. Model independent OAuth requirements as conjunctive groups so repository deletion requires both delete_repo and repo.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
@JoannaaKL

Copy link
Copy Markdown
Collaborator

Reviewed this closely because it is destructive and security-sensitive. Two blockers, both reproducible.

Blocker 1: the confirmation does nothing in stdio mode

StateSealer is set in one place, pkg/http/server.go:175. internal/ghmcp never sets it, so requestStateSealerFromDeps returns nil in stdio and every binding check is skipped: no request state, no target binding, no repository ID check, no TTL.

What is left is confirmation.Content["repository_name"] == owner + "/" + repo. Both sides come from the client. InputResponses is client-controlled, and with a nil sealer nothing ties it to a server-issued InputRequests.

I called the handler directly with BaseDeps{} (no sealer), one tools/call, empty RequestState, and InputResponses filled in by the caller:

IsError            = false
result             = "Repository owner/repo was deleted."
DELETE calls       = 1
identity GET calls = 0
round trips used   = 1 (elicitation never requested)

The repository is deleted in one round trip and the user is never asked anything.

This is also why invokeDeleteRepository in repositories_test.go passes: it builds a single request with InputResponses already populated, which is the same shape as the bypass. The happy-path test does not exercise a real confirmation.

stdio is the primary target for this server, so the tool ships without the control it advertises.

Repro
func TestStdioModeConfirmationIsBypassable(t *testing.T) {
	client := NewMockedHTTPClient(
		WithRequestMatchHandler(DeleteReposByOwnerByRepo,
			mockResponse(t, http.StatusNoContent, nil)),
	)
	deps := BaseDeps{Client: mustNewGHClient(t, client)}
	require.Nil(t, deps.GetRequestStateSealer()) // stdio never configures one

	tool := DeleteRepository(translations.NullTranslationHelper)
	request := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"})

	// Server never issued an elicitation. Client asserts the user confirmed.
	request.Params.InputResponses = mcp.InputResponseMap{
		deleteRepositoryConfirmationID: &mcp.ElicitResult{
			Action:  "accept",
			Content: map[string]any{deleteRepositoryConfirmationField: "owner/repo"},
		},
	}
	request.Params.RequestState = ""

	result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request)
	require.NoError(t, err)
	require.False(t, result.IsError) // passes: repository deleted
}

Blocker 2: scope checks changed from ANY to ALL for every tool

NewTool and NewToolFromHandler now set RequiredScopeGroups on every tool, and CreateToolScopeFilter uses HasRequiredScopeGroups whenever that field is non-empty.

HasRequiredScopes is ANY-of. HasRequiredScopeGroups is ALL-of. So every tool declaring more than one scope changes meaning, not just the new one.

Three existing tools declare {Repo, ReadOrg}:

  • pkg/github/issue_fields.go:132list_issue_fields
  • pkg/github/issues.go:1112list_issue_types
  • pkg/github/ui_tools.go:71ui_get

Running CreateToolScopeFilter([]string{"repo"}) against origin/main and this branch:

tool origin/main this PR
list_issue_fields visible hidden
ui_get visible hidden

list_issue_types is affected the same way.

repo is the common PAT scope, so these tools disappear for a lot of users, with no error and no migration note. The added scope tests only cover a synthetic tool, so nothing catches this.

Consequence: the README is now wrong

cmd/github-mcp-server/generate_docs.go:224 still states the old rule:

// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
// so when multiple required scopes are listed, render them as alternatives
// rather than implying all are required.

The generator was not updated, so the README documents "any of" for tools that now require all three entries:

  • README.md:947list_issue_fields: repo, read:org
  • README.md:953list_issue_types: repo, read:org
  • README.md:1312delete_repository: delete_repo, repo

delete_repository is documented as needing either scope; it needs both. script/generate-docs passes because it regenerates the same wrong text.

To unblock

  1. Make the sealer mandatory for this tool. Refuse the deletion when there is no sealer, or give stdio one. Today the check is skipped exactly where the tool is most used.
  2. Replace the happy-path test with one that goes through a real elicitation round trip, so a fabricated InputResponses cannot pass.
  3. Revert the global ANY to ALL change. Apply the conjunctive rule only to delete_repository.
  4. Add a regression test pinning list_issue_fields, list_issue_types and ui_get as visible with a repo-only token.
  5. Update generate_docs.go to render conjunctive groups, then regenerate the README.
  6. Cover the untested branches on the delete path, starting with RequestState == "" (repositories.go:817). DeleteRepository is at 79.2% and most of the uncovered branches are the refusal paths.

The availability work in pkg/inventory is solid, the {}-means-form handling correctly matches the go-sdk, and the AES-256-GCM usage looks right. The problem is that the guarantee only holds on the HTTP path.

Give stdio a process-local request-state sealer and make deletion fail closed without one. Preserve legacy any-of OAuth behavior globally while documenting and enforcing delete_repository's conjunctive delete_repo and repo requirements.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
@SamMorrowDrums

Copy link
Copy Markdown
Collaborator Author

Addressed both blockers from the latest review:

  • delete_repository now fails closed without a request-state sealer, and stdio creates a process-local random sealer at startup. The handler tests now perform a real issue-and-retry flow and explicitly cover fabricated inputResponses plus missing requestState.
  • Restored legacy ANY-of semantics for existing multi-scope tools. Conjunctive scope groups are now applied only to delete_repository; regression coverage pins list_issue_fields, list_issue_types, and ui_get as visible to repo-only tokens.
  • Generated docs now label delete_repository scopes as all required.

Full lint and race suites pass on 2dce589e.

Include delete_repo in the supported OAuth scope set used by stdio login, HTTP protected-resource metadata, and tool filtering.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
Keep delete_repo in protected-resource discovery for step-up authorization while excluding it from the default stdio OAuth grant.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
Generate protected-resource supported scopes and the lower-risk default OAuth grant from one canonical scope definition list.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
Move supported and default OAuth scope policy into pkg/scopes so protected-resource metadata and stdio grants derive from the scope domain package.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
Keep workflow and codespace in protected-resource discovery while excluding both from the default OAuth grant alongside delete_repo.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
@SamMorrowDrums
SamMorrowDrums merged commit 8ec6249 into main Aug 18, 2026
19 checks passed
@SamMorrowDrums
SamMorrowDrums deleted the sammorrowdrums-add-delete-repository-tool branch August 18, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants